Skip to content

Add DeepZoom static preview services and samples#379

Open
mattleibow wants to merge 92 commits intomainfrom
feature/deep-zoom-static-preview
Open

Add DeepZoom static preview services and samples#379
mattleibow wants to merge 92 commits intomainfrom
feature/deep-zoom-static-preview

Conversation

@mattleibow
Copy link
Collaborator

Summary

Extracts the core DeepZoom static rendering system. This is the minimal set of services needed to load and render Deep Zoom collections and images without gestures, animations, or custom controls.

What's included

  • DeepZoom collection/image source parsers — DZC/DZI XML parsing (SKDeepZoomCollectionSource, SKDeepZoomImageSource)
  • Tile infrastructure — cache (SKDeepZoomTileCache), scheduler (SKDeepZoomTileScheduler), and HTTP/file fetchers
  • Controller (SKDeepZoomController) — orchestrates loading and delegates to tile services
  • Viewport (SKDeepZoomViewport) — centered-fit geometry (aspect ratio preserved, no cropping/stretching)
  • Renderer (SKDeepZoomRenderer) — draws best-resolution tiles onto SKCanvas
  • 436 unit tests in a dedicated SkiaSharp.Extended.DeepZoom.Tests project
  • Blazor sample page (Pages/DeepZoom.razor) — minimal static preview using SKCanvasView
  • MAUI sample page (Demos/DeepZoom/DeepZoomPage) — same concept, uses app-package assets
  • Documentationdeep-zoom.md, deep-zoom-blazor.md, deep-zoom-maui.md

What's NOT included (intentionally)

  • ❌ No gesture system (SKGestureTracker, SKGestureDetector, etc.)
  • ❌ No animation system (SKAnimationSpring, SKAnimationTimer, etc.)
  • ❌ No custom MAUI SKDeepZoomView control
  • ❌ No user interaction beyond canvas size

Architecture

The sample page passes a URI to SKDeepZoomController, which loads the DZI/DZC, manages tiles, and fires InvalidateRequired when new tiles are ready. On each paint, the page calls SetControlSize() then Render(). The only input is the canvas size — the image is always centered-fit.

Tests

dotnet test tests/SkiaSharp.Extended.DeepZoom.Tests/  # 436 pass
dotnet test tests/SkiaSharp.Extended.Tests/           # 188 pass (unchanged from main)
dotnet build samples/SkiaSharpDemo.Blazor/            # Build succeeded

Related: #378

Extract the core DeepZoom rendering system from PR #378:
- DeepZoom collection/image source parsers (DZC/DZI XML)
- Tile cache, scheduler, and HTTP/file fetchers
- Controller (orchestrates loading), viewport (centered-fit geometry), renderer (draws tiles)
- 436 unit tests (animation tests excluded — no dependency on gestures/animations)
- Blazor sample page (Deep Zoom Preview) with testgrid DZI static asset
- MAUI sample page (Deep Zoom Preview) with testgrid DZI app-package asset
- Documentation: deep-zoom.md, deep-zoom-blazor.md, deep-zoom-maui.md
- Solution file updated to include DeepZoom test project
- DeepZoom added to MAUI ExtendedDemos and Blazor NavMenu

No gestures, animations, or custom controls included.
This is a minimal static preview system for gigapixel images.
Images render centered-fit (aspect ratio preserved, no cropping/stretching).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
@github-actions
Copy link

github-actions bot commented Mar 13, 2026

📖 Documentation Preview

The documentation for this PR has been deployed and is available at:

🔗 View Staging Documentation

🔗 View Staging Blazor Sample

This preview will be updated automatically when you push new commits to this PR.


This comment is automatically updated by the documentation staging workflow.

… add README

- Blazor: remove duplicated testgrid assets from wwwroot/deepzoom/
- Blazor: load DZI directly from GitHub raw URL (no local duplication)
- Blazor: switch SKCanvasView → SKGLView for hardware-accelerated rendering
- Controller: SetControlSize() now refits viewport when canvas size changes,
  ensuring centered-fit works correctly in any aspect ratio (portrait/landscape)
- DeepZoom: add README.md with architecture overview and Mermaid diagrams
  showing component relationships, tile pipeline, and rendering flow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
- SKDeepZoomTileRequest: correct fields are {TileId, Priority}, not IsFallback/FallbackParent
- SKDeepZoomCollectionSubImage.Source is string? (URI path), not SKDeepZoomImageSource
- Remove incorrect inheritance arrow from SubImage → ImageSource

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
Blazor (DeepZoom.razor):
- Canvas now fills available viewport height using flexbox + calc(100vh)
- Added collapsible debug inspector sidebar showing: image source info
  (dimensions, tile size, levels, format, aspect ratio), viewport state
  (control size, origin, scale, zoom), tile cache stats (count/max/pending),
  and a live tile table with level/col/row/priority/cached status
- Added PendingTileCount property to SKDeepZoomController for inspector
- Inspector refreshes every 250ms during tile loading
- Window resize triggers canvas invalidate via JS interop
- Toggle button (fixed bottom-right) shows/hides the inspector

MAUI (DeepZoomPage.xaml.cs):
- Removed AppPackageFetcher and embedded testgrid assets
- Now fetches DZI and tiles from GitHub raw URL (same as Blazor)
- Removed TestGrid/ from Resources/Raw/

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
- Add deepZoomUnregisterResize JS function to remove the resize handler;
  call it in DisposeAsync so listeners don't accumulate on page navigation
- Switch Blazor page from IDisposable → IAsyncDisposable to cleanly await
  the JS interop call during disposal
- Use parameterless SKDeepZoomHttpTileFetcher() in both Blazor and MAUI
  samples so the fetcher owns and disposes its HttpClient (_ownsClient=true)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
- Blazor: zoom slider (0.1–10×) in toolbar + mouse drag to pan
  - Reset view button returns to fit-to-view
  - Inspector zoom label auto-syncs with viewport state
- MAUI: Slider in toolbar for zoom + PanGestureRecognizer for drag pan
  - Toolbar shows current zoom level label
- Both samples call controller.Pan() and controller.SetZoom() APIs
- Bare minimum interaction for testing tile loading at various zoom levels

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
- Remove pan direction inversion in both Blazor and MAUI samples
- Fix MAUI pan to compute frame-to-frame delta from TotalX/TotalY

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
- Remove MaxViewportWidth = fitWidth from FitToView() so users can
  zoom out beyond the initial fit without snapping back
- Track _userHasZoomed in controller; SetControlSize no longer resets
  the viewport to fit when the user has manually adjusted zoom/pan
- ResetView() and Load() clear the flag to restore auto-refit behaviour
- All pan/zoom methods set the flag

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
FitToView() no longer sets MaxViewportWidth to the fit width,
so users can zoom out past the initial fit level. Update the test
to assert double.MaxValue and update the comment accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
Zoom range:
- Blazor and MAUI slider max increased 10x → 50x (step 0.05)
- Native 1:1 pixel zoom for 8192px image on 1500px canvas is ~5.46x,
  so 50x allows substantial upscaling to inspect tile quality

Inspector - new 'Level Selection' section:
- Current level and max level (e.g. '13 / 13 ⚠️')
- Level dimensions (e.g. '8192 × 8192 px')
- Tile actual size (e.g. '256 × 256 px' - the stored file dimensions)
- Tile on screen size (pixels the tile occupies at current zoom)
- Native 1:1 zoom indicator
- Warning message when at max level: 'Max detail — native resolution
  reached, zooming further upscales tiles'

Level selection explanation (why it stays at level 13 from zoom 2.73+):
The testgrid is 8192×8192, max level = 13. GetOptimalLevel selects the
lowest level where levelWidth > controlWidth/viewportWidth. For a 1500px
canvas, level 13 (8192px) is selected at zoom ≥ 8192/1500/~2 ≈ 2.73x.
Beyond that there are no higher levels, so it stays at 13 — correct.

Testgrid DZI has Overlap=0 (tiles were generated without overlap pixels).
The generate-dzi.cs script already defaults to overlap=1 for new images.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
Inspector bug fix:
- tilePxH was computed as (TileSize * Scale / levelH) * AspectRatio which
  is wrong for non-square images. Correct formula divides by AspectRatio:
  tilePxH = TileSize * Scale / levelH / AspectRatio

Testgrid regeneration:
- Recreated 8192x8192 source image as a colored grid pattern with cell
  labels (using PIL in a helper script)
- Ran scripts/generate-dzi.cs with --overlap 1 to generate all 14 levels
  (0-13) with 1px tile overlap as the user requested
- testgrid.dzi now has Overlap='1' instead of Overlap='0'
- Also committed scripts/generate-dzi.cs to this branch for reproducibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
Previously the ⚠️ appeared whenever level 13 was selected, even at zoom
2.73x where it is the correct level and no upscaling is happening.

Now 'isUpscaling' is true only when zoom > nativeZoom (the 1:1 pixel
mapping threshold, ≈ imageWidth / controlWidth). This means the warning
appears only when the renderer would need a higher level than maxLevel
but none exists — i.e. tiles are genuinely being upscaled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
Both Blazor and MAUI samples now bake the current git branch into the
assembly at build time via AssemblyMetadataAttribute('GitBranch', ...).
At runtime the URL is constructed from the embedded branch name, so:
- main builds fetch tiles from main
- PR builds fetch tiles from their own branch

Auto-detection: A 'DetectGitBranch' MSBuild target runs 'git rev-parse
--abbrev-ref HEAD' before the build. Falls back to 'main' if git is not
available or the property is explicitly overridden by CI via -p:GitBranch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
Previous approach added AssemblyAttribute items inside a target's
<ItemGroup> before GenerateAssemblyInfo — but GenerateAssemblyInfo
had already captured the item list at evaluation time, so GitBranch
was always 'main'.

New approach: a 'DetectAndWriteGitBranch' target (BeforeTargets=CoreCompile)
writes a small generated .cs file to the intermediate output directory and
adds it to the Compile item group at target execution time. This is picked
up by CoreCompile (the actual C# compiler), so the branch value is correct.

Also fixed:
- MAUI csproj had unclosed <PropertyGroup> tag (Bug 1)
- Detached HEAD fallback ('HEAD' → 'main') for CI shallow checkouts (Bug 3)

Verification on branch 'feature/deep-zoom-static-preview':
  obj/.../GitBranchInfo.cs contains 'feature/deep-zoom-static-preview'
  The DLL contains the correct branch name at offset 90216

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
- Reads GitHub Actions (GITHUB_HEAD_REF, GITHUB_REF_NAME) env vars
- Reads Azure DevOps (BUILD_SOURCEBRANCH) env var with refs/heads/ stripping
- Falls back to local git, then to 'main'
- Config embedded as resource, read at runtime via GetManifestResourceStream
- Removes AssemblyMetadataAttribute/GitBranchInfo.cs approach

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause: the right edge of tile N was computed as
  (float)topLeft.X + (float)(screenRight - topLeft.X)
while the left edge of tile N+1 was independently computed as
  (float)screenRight
Float arithmetic makes (float)A + (float)(B-A) ≠ (float)B in general,
producing sub-pixel gaps (measured 3e-5 to 6e-5px) that flickered as the
viewport moved. IIIF images showed this visibly since they have no overlap
to mask seams; DZI was less affected due to the 1px tile overlap.

Fix: pixel-snap all four corners in GetTileDestRect using Math.Floor for
the top-left and Math.Ceiling for the bottom-right. This guarantees that
adjacent tiles share the same integer pixel boundary:
- ceil(shared_boundary) ≥ floor(shared_boundary) always holds (no gap)
- At most 1px overlap (tile N+1 covers tile N's last pixel)
- Seamless appearance regardless of viewport position or zoom level

Add two regression tests:
- TileLayout_GetTileDestRect_AdjacentIiifTiles_NoGap: verifies no gap with
  non-zero viewport origin (the worst-case for float precision drift)
- TileLayout_GetTileDestRect_AdjacentDziTiles_NoGap: verifies DZI still has
  no gap (overlap ≥ 0) after the change

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 18, 2026
Restore resources/collections/testgrid/ to exactly match main.
The PR had deleted all tile levels below 14-15 and created a fresh
testgrid.dzi; this restores the full set of levels (0-15) and the
original testgrid.dzi (no trailing newline) from main.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 18, 2026
mattleibow and others added 2 commits March 18, 2026 23:54
…Point<T>

- Add Rect<T> and Point<T> as readonly record structs (where T : struct).
  No INumber<T> constraint so both netstandard2.0 and net9/10 are supported.
  record struct auto-generates Deconstruct, so existing tuple-pattern code
  in tests (var (x, y, w, h) = ...) is unchanged.

- Remove SKImagePyramidRectI and SKImagePyramidRectF (deleted files).

- Update ISKImagePyramidSource.GetTileBounds: SKImagePyramidRectI → Rect<int>
- Update ISKImagePyramidRenderer.DrawTile/DrawFallbackTile: SKImagePyramidRectF → Rect<float>
- Update SKImagePyramidDziSource, SKImagePyramidIiifSource: return Rect<int>
- Update SKImagePyramidTileLayout: return Rect<float>, replace .Right/.Bottom
  with inline X+Width/Y+Height arithmetic
- Update SKImagePyramidViewport: ElementToLogicalPoint/LogicalToElementPoint
  return Point<double>; GetZoomRect returns Rect<double>
- Update SKImagePyramidSubImage: GetMosaicBounds returns Rect<double>;
  ParentToLocal/LocalToParent return Point<double>
- Update SKImagePyramidController.GetZoomRect: return Rect<double>
- Update SKImagePyramidRenderer.DrawTile/DrawFallbackTile: Rect<float> params,
  inline right/bottom arithmetic
- Update DebugBorderRenderer: Rect<float> list and params, inline arithmetic
- Update edge-case tests: replace .Right with .X + .Width assertions

All 463 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace deleted SKImagePyramidRectI and SKImagePyramidRectF with the new
generic Rect<int> and Rect<float> in controller, iiif, and deepzoom docs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 18, 2026
## Bugs fixed

### HIGH: GetVisibleTiles returns spurious tiles outside image bounds
After clamping pixel coords to level bounds, add early-out when
pixelRight <= pixelLeft or pixelBottom <= pixelTop. Prevents tile (0,0,0)
from being fetched when viewport is entirely to the left or above the image.
Regression tests: GetVisibleTiles_ViewportEntirelyLeftOfImage_ReturnsEmpty,
                  GetVisibleTiles_ViewportEntirelyAboveImage_ReturnsEmpty

### HIGH: SKImagePyramidRenderer hard-casts ISKImagePyramidTile → crash with custom decoders
Replace bare (SKImagePyramidImageTile)tile with 'tile is not SKImagePyramidImageTile; return'.
Custom decoders producing a different ISKImagePyramidTile implementation no longer throw
InvalidCastException — the renderer silently skips tiles it cannot handle.

### HIGH: IIIF scale factor ≤ 0 silently corrupts all pyramid geometry
A scale factor of 0 caused (int)(+∞) = int.MinValue in GetLevelWidth/Height,
poisoning tile counts and dest rects. Two defences added:
1. ParseDocument: filter non-positive values from JSON scaleFactors before use.
2. Constructor: throw ArgumentException if any scale factor ≤ 0.
Regression test: IiifSource_ZeroScaleFactor_ThrowsArgumentException

### HIGH: Tile resource leak — TOCTOU race in LoadTileAsync → PutAsync
The check '!ct.IsCancellationRequested' at line 453 and the same check inside
PutAsync created a window where the tile was neither stored nor disposed.
Fix: pass CancellationToken.None to PutAsync — the store decision was already
made; there is no reason to re-check cancellation inside PutAsync.

### HIGH: Infinite retry storm for permanently failing tiles (HTTP 404 etc.)
LoadTileAsync already received null from FetchTileAsync but never recorded it,
so ScheduleTileLoads re-queued the same tile every render frame (up to 60×/s).
Fix: Add _failedTiles ConcurrentDictionary. When FetchTileAsync returns null,
record in _failedTiles and return. ScheduleTileLoads skips tiles in _failedTiles.
_failedTiles is cleared on Load() and ReplaceCache() so retries happen on reload.

### MEDIUM: GetZoomRect returns wrong visible height
GetZoomRect used viewportWidth/AspectRatio which is the image's logical height,
not the actual visible height of the control. For any control shape other than
matching the image aspect ratio, the result was wrong.
Fix: height = viewportWidth * controlHeight / controlWidth (same as ViewportHeight).
Updated 5 tests that were written to match the buggy behavior.

### MEDIUM: Use-after-dispose of fetcher in LoadTileAsync
Background tasks captured _tileSource/_fetcher as fields, creating a window
where Dispose/Load could replace them after a task passed its cancellation check.
Fix: capture source and fetcher as local variables at the start of LoadTileAsync.

All 466 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 18, 2026
## Bugs fixed

### HIGH: SKPathEffect leaked every render frame in debug zoom
SKPathEffect.CreateDash() returns a native handle that SKPaint.Dispose()
does NOT dispose. Extracted to 'using var dashEffect' before the paint so
it is properly disposed after DrawRect.

### HIGH: BrowserStorageTileCache leaks overwritten tile
AddToMemIndex used ContainsKey then _memIndex[id] = tile without disposing
the old entry. Changed to TryGetValue + conditional dispose (with reference
equality guard to avoid self-dispose).

### MEDIUM: OnExampleSelected missing concurrent-load guard
Added 'if (_urlLoading) return;' at entry so rapid example selection while
a load is in progress is ignored.

## Docs fixed (7 issues)

- controller.md:164 — Rect<float> has no .Right/.Bottom; use X+Width/Y+Height
- controller.md:423 — SKImagePyramidTileScheduler → SKImagePyramidTileLayout
- caching.md:91    — controller.Render(e.Surface.Canvas) → controller.Render(renderer)
- blazor.md:123    — SKImagePyramidHttpTileFetcher() missing decoder argument
- blazor.md:154    — FitToView() → ResetView() (method was renamed)
- maui.md:207      — FitToView() → ResetView()

## Tests

- Renamed TileSchedulerTest.cs → TileLayoutTest.cs (class inside was TileLayoutTest)
- Added GeometricTypesTest.cs: 16 tests covering Rect<int/float/double> and
  Point<int/float/double> construction, equality, deconstruct, default values,
  and inline right/bottom arithmetic patterns.

482 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 18, 2026
…Image, Rect<float> with SKRect

- Delete SkiaSharp.Extended.Abstractions project (merge all files into SkiaSharp.Extended)
- Delete ISKImagePyramidTile, ISKImagePyramidTileDecoder, SKImagePyramidImageTile, SKImagePyramidImageTileDecoder
- ISKImagePyramidTileFetcher/Cache/Renderer now use SKImage directly
- SKImagePyramidHttpTileFetcher/FileTileFetcher inline SKImage.FromEncodedData, no decoder param
- SKImagePyramidTileLayout returns SKRect instead of Rect<float>
- SKImagePyramidRenderer simplified: no type check, SKImage+SKRect directly
- Update tests, Blazor sample (BrowserStorageTileCache, DelayTileCache, DebugBorderRenderer, ImagePyramid.razor)
- Delete GeometricTypesTest.cs (tests were for now-internal Rect<T> types)
- All 466 tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 19, 2026
- Fix ImagePyramidPage.xaml.cs: 3 SKImagePyramidHttpTileFetcher() calls no longer pass decoder
- caching.md: replace ISKImagePyramidTile with SKImage in interface listing and all examples
- fetching.md: remove ISKImagePyramidTileDecoder injection pattern, inline SKImage.FromEncodedData
- controller.md: fix HttpTileFetcher calls and TileBorderRenderer decorator example (SKRect/SKImage)
- index.md: remove Abstractions note, remove decoder from mermaid/table, fix quick-start code
- maui.md: fix AppPackageFetcher class, HttpTileFetcher constructor, usage snippets
- deepzoom.md / iiif.md / blazor.md: fix HttpTileFetcher constructor calls

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 19, 2026
…lesystem + browser caches

- Add SKImagePyramidTile sealed class holding SKImage + RawData (byte[])
  - No re-encoding needed for L2 caches; raw bytes stored alongside decoded image
  - Implements IDisposable, disposing the inner SKImage
- Update fetcher pipeline to buffer bytes before decoding (handles forward-only streams)
  - SKImagePyramidHttpTileFetcher: ReadAsByteArrayAsync then FromEncodedData
  - SKImagePyramidFileTileFetcher: ReadAllBytes then FromEncodedData
- Update all interfaces: ISKImagePyramidTileFetcher, ISKImagePyramidTileCache,
  ISKImagePyramidRenderer now use SKImagePyramidTile instead of bare SKImage
- Add SourceId to ISKImagePyramidSource (FNV-1a hash of URI + dimensions)
  Implemented in DziSource, IiifSource, DziCollectionSource
- SKImagePyramidController now requires ISKImagePyramidTileCache in constructor
- Add SKImagePyramidFileSystemTileCache: two-tier memory+disk cache
  Stores tile.RawData to disk, re-decodes on L2 hit, promotes to L1 memory cache
- Add SkiaSharp.Extended.UI.Blazor project with SKImagePyramidBrowserStorageTileCache
  Stores tile.RawData as base64 in sessionStorage (no re-encoding)
- Update Blazor sample: BrowserStorageTileCache uses tile.RawData, DelayTileCache and
  DebugBorderRenderer updated to SKImagePyramidTile
- Update MAUI sample: ImagePyramidPage passes SKImagePyramidMemoryTileCache to controller
- Update all 466 tests (all passing)
- Update docs: fetching.md, caching.md, controller.md, maui.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 20, 2026
- Fix caching.md: update all code block signatures from SKImage? to SKImagePyramidTile?
  (interface listing, TieredCache example, BoundedDictionaryCache example)
- Fix TOCTOU race in SKImagePyramidFileSystemTileCache._cleanupRunning:
  change bool to int, use Interlocked.CompareExchange to guard cleanup entry
  and Interlocked.Exchange(ref _cleanupRunning, 0) in finally block
- Cache SourceId in all 3 source types (lazy _sourceId ??= FnvHash(...))
  to avoid recomputing the hash on every property access

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 20, 2026
…lders; add SourceId flow and cache expiry

Subfolder reorganization (git mv, history preserved):
- ImagePyramid/Caching/: ISKImagePyramidTileCache, SKImagePyramidMemoryTileCache, SKImagePyramidFileSystemTileCache
- ImagePyramid/Fetching/: ISKImagePyramidTileFetcher, SKImagePyramidHttpTileFetcher, SKImagePyramidFileTileFetcher, SKImagePyramidTile
- ImagePyramid/Sources/: ISKImagePyramidSource, SKImagePyramidDziSource, SKImagePyramidIiifSource, SKImagePyramidDziCollectionSource, SKImagePyramidDziCollectionSubImage, SKImagePyramidSubImage, SKImagePyramidDisplayRect
- Namespaces unchanged (SkiaSharp.Extended)

SourceId flow through tiles:
- SKImagePyramidTile: add SourceId property (optional constructor param, default "")
- ISKImagePyramidTileCache: add ActiveSourceId { get; set; } to interface
- SKImagePyramidMemoryTileCache: implement ActiveSourceId (ignored, stored only)
- SKImagePyramidFileSystemTileCache: remove sourceId from constructor; use ActiveSourceId for directory namespacing; WriteToDiskAsync uses tile.SourceId
- SKImagePyramidController.Load(): sets _cache.ActiveSourceId = source.SourceId
- SKImagePyramidController.LoadTileAsync(): stamps tile.SourceId from source after fetch
- BrowserStorageTileCache (sample + library): implement ActiveSourceId (ignored)
- DelayTileCache (sample): delegate ActiveSourceId to inner cache

Cache expiry:
- ISKImagePyramidSource: add CacheExpiry { get; } property
- SKImagePyramidDziSource: CacheExpiry = null (static content, cache forever)
- SKImagePyramidIiifSource: CacheExpiry = 7 days (server content may update)
- SKImagePyramidDziCollectionSource: CacheExpiry = null (static content)
- SKImagePyramidFileSystemTileCache: add Expiry property (default 30 days via DefaultExpiry static); TryGetAsync deletes and returns null for expired tiles
- SKImagePyramidController.Load(): applies source.CacheExpiry to filesystem cache if applicable

Tests: 472 passing (+6 new tests for FileSystemTileCache expiry, ActiveSourceId, and SourceId)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 20, 2026
mattleibow and others added 9 commits March 21, 2026 01:27
…settable, Clear deletes all

- SKImagePyramidFileSystemTileCache: rename static DefaultExpiry -> DefaultMaxExpiry;
  add public DefaultExpiry instance property storing ctor value;
  initialize Expiry = DefaultExpiry in ctor (eliminates fragile _expiry fallback)
- TryGetAsync: use Expiry directly (no more effectiveExpiry fallback logic)
- Clear(): delete entire _basePath instead of active source subdirectory only
- SKImagePyramidController.Load(): always set fsCache.Expiry using
  'tileSource.CacheExpiry ?? fsCache.DefaultExpiry' so expiry resets
  correctly when switching from IIIF (7 days) back to DZI (no expiry)
- SKImagePyramidTile.SourceId: change to { get; internal set; } so controller
  can stamp SourceId without creating a redundant wrapper object

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…controller

The controller no longer manages disk caching or the fetch pipeline.
Instead it holds an internal memory render buffer and delegates all
tile acquisition to ISKImagePyramidTileProvider.

Key changes:
- Add ISKImagePyramidTileProvider interface (GetTileAsync(url, ct))
- Add SKImagePyramidHttpTileProvider: HTTP fetch + optional URL-keyed
  disk cache using FNV-1a hash; expiry-aware; atomic writes via tmp file
- Add SKImagePyramidFileTileProvider: local file read, no disk cache
  (the file IS the source)
- SKImagePyramidController: parameterless constructor, internal
  _renderBuffer (256-entry memory cache), Load/ReplaceProvider API.
  No more TryGetAsync/PutAsync in LoadTileAsync.
- Remove ActiveSourceId from ISKImagePyramidTileCache and memory cache
  (source namespacing now handled by URL-keyed provider)
- Blazor sample: DelayTileProvider + BrowserStorageTileProvider replace
  DelayTileCache + BrowserStorageTileCache; same decorator pattern
- MAUI sample: parameterless controller, HttpTileProvider
- All 472 tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Delete ISKImagePyramidTileFetcher, SKImagePyramidHttpTileFetcher,
  SKImagePyramidFileTileFetcher (replaced by provider pattern)
- SKImagePyramidHttpTileProvider: ThrowIfCancellationRequested at entry;
  rethrow OperationCanceledException when ct.IsCancellationRequested
  so cancelled tiles are not blacklisted in _failedTiles. HTTP timeouts
  (TaskCanceledException with ct not cancelled) still return null.
- Fix TileFetcherTest and TileFetchersTest: cancellation now expects
  OperationCanceledException instead of null
- Fix controller XML doc: ISKImagePyramidTileFetcher → ISKImagePyramidTileProvider
- Fix SKImagePyramidFileSystemTileCache XML doc: remove stale
  ISKImagePyramidTileCache.ActiveSourceId reference
- Rewrite fetching.md to describe ISKImagePyramidTileProvider pattern
- Update index.md, controller.md, blazor.md, maui.md, iiif.md,
  deepzoom.md: replace all fetcher references with provider equivalents

All 472 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- ISKImagePyramidTileCache is now sync-only: remove TryGetAsync, PutAsync,
  and nullable tile parameter from Put. The interface represents only the
  controller's internal hot render buffer, not a general-purpose cache tier.

- SKImagePyramidMemoryTileCache is now internal sealed: users never create
  it directly; the controller owns it. Expose via InternalsVisibleTo for tests.

- Delete SKImagePyramidFileSystemTileCache: orphaned dead code. Disk caching
  now lives inside HttpTileProvider (or custom provider decorators).

- Delete SKImagePyramidBrowserStorageTileCache from the Blazor library:
  replaced by BrowserStorageTileProvider in the Blazor sample, which correctly
  implements ISKImagePyramidTileProvider instead.

- Add source/SkiaSharp.Extended/Internals.cs with InternalsVisibleTo for
  the test assembly so tests can still unit-test SKImagePyramidMemoryTileCache.

- TileCacheTest: replace null tile placeholders with real SKImagePyramidTile
  instances via MakeTile() helper; delete FileSystemTileCacheTest class;
  migrate the two tile-identity tests into TileCacheTest.

- Update caching.md to reflect the new two-concern design: render buffer
  (controller-owned, sync) vs persistent storage (provider-owned, async).

All 468 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- controller.TileSource → controller.Source (3 files)
- controller.ReplaceProvider() → controller.SetProvider() (1 file)
- SKImagePyramidFileTileProvider → SKTieredTileProvider(new SKFileTileFetcher()) (3 files)
- SKImagePyramidHttpTileProvider → SKTieredTileProvider(new SKHttpTileFetcher()) (3 files)
- Remove SourceId tests (constructor param removed) (1 file)
- Add tests for new nullable rawData constructor

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Split the monolithic HttpTileProvider (which mixed HTTP fetching with disk
caching) into separate, composable interfaces:

- ISKTileFetcher: pure origin fetch (HTTP, file, composite)
- ISKTileCacheStore: persistent storage (disk, null, chained)
- SKTieredTileProvider: composes fetcher + cache store
- SKImagePyramidTileData: shared data wrapper for the pipeline
- TileFailureTracker: exponential backoff replacing permanent blacklist

Controller API changes:
- SetProvider()/Load() decoupled (provider set once, sources swap freely)
- Source/Provider exposed as read-only properties
- Memory cache injectable via constructor for testing/observability
- RetryFailedTiles() for clearing transient failures

Other changes:
- SKImagePyramidTile.RawData now nullable (render-only tiles)
- SourceId removed from tiles (URL-keyed caching sufficient)
- SKImagePyramidMemoryTileCache promoted to public
- DZI Parse(xml, Uri) auto-derives TilesBaseUri from convention
- Old SKImagePyramidHttpTileProvider/FileTileProvider removed

Platform composition is now trivial:
- Desktop: SKHttpTileFetcher + SKDiskTileCacheStore
- WASM: SKHttpTileFetcher + custom IndexedDbTileCacheStore
- MAUI bundled: MauiAssetTileFetcher (no cache needed)
- MAUI hybrid: SKCompositeTileFetcher(Asset, Http) + DiskCache

All 656 tests pass (468 ImagePyramid + 188 core).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ts; update docs

Provider lifecycle fixes:
- Blazor ImagePyramid.razor RebuildProvider(): dispose old _delayProvider before
  replacing (controller does NOT own provider lifecycle). Remove false comments.
- Blazor ImagePyramid.razor DisposeAsync(): always dispose _delayProvider
  explicitly, regardless of whether controller was set.
- MAUI ImagePyramidPage: add _provider field; replace inline new-per-load
  SKTieredTileProvider(new SKHttpTileFetcher()) calls with ReplaceProvider()
  helper that disposes old before assigning new, preventing HttpClient leaks.

BrowserStorageTileProvider fixes:
- Line 24: change `if (ct.IsCancellationRequested) return null` to
  `ct.ThrowIfCancellationRequested()` — returning null causes controller to
  permanently record a failure via TileFailureTracker.
- Line 36: guard `WriteToBrowserAsync` call with `if (tile.RawData != null)`
  — RawData is nullable and would NullReferenceException at base64 conversion.

Tests (33 new, total 501):
- TileFailureTrackerTest: backoff timing, exponential delay, max retries,
  Reset, ResetAll
- SKNullTileCacheStoreTest: always-null reads, no-op writes
- SKDiskTileCacheStoreTest: round-trip, miss, expiry, remove, clear, bucketing
- SKChainedTileCacheStoreTest: first-wins reads, writes-to-all, empty ctor throws
- SKCompositeTileFetcherTest: first-wins, fallthrough, all-miss, cancellation
- SKTieredTileProviderTest: cache hit, miss+fetch, null fetch, cancellation, persist

Docs: rewrite fetching.md for new ISKTileFetcher/ISKTileCacheStore/SKTieredTileProvider
architecture; fix all stale SKImagePyramidHttpTileProvider/SKImagePyramidFileTileProvider
references across controller.md, caching.md, index.md, blazor.md, maui.md, iiif.md,
deepzoom.md; convert old constructor args (diskCachePath, httpClient) to new
SKTieredTileProvider(fetcher, cacheStore) pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extract canvas/controller/rendering logic from sample pages into
reusable ImagePyramidView components, separating rendering concerns
from the debug/inspector UI.

## Blazor (samples/SkiaSharpDemo.Blazor/)
- Add Components/ImagePyramid/ImagePyramidView.razor + .razor.cs
  - Owns SKGLView, SKImagePyramidController, default renderer
  - Parameters: Source (ISKImagePyramidSource), Provider (ISKImagePyramidTileProvider),
    Renderer (ISKImagePyramidRenderer), DebugZoom, MinZoom, MaxZoom, OnInvalidate
  - Exposes Controller (read-only), Zoom, ResetView(), SetZoom(), SyncZoom()
  - Handles pan/touch gestures and JS resize registration internally
  - Uses OnParametersSet for proper Blazor parameter change detection
- Refactor Pages/ImagePyramid.razor to use <ImagePyramidView>
  - Page retains: URL loading, source selection, inspector wiring,
    delay/cache provider construction, zoom slider
  - Page no longer owns: canvas element, controller, renderer, pan handlers

## MAUI (samples/SkiaSharpDemo/)
- Add Demos/ImagePyramid/ImagePyramidView.cs
  - ContentView wrapping SKCanvasView + SKImagePyramidController
  - Bindable Source and Provider properties trigger controller.Load()
  - Initialize()/Cleanup() called from page OnAppearing/OnDisappearing
  - Exposes Controller (read-only), Zoom, ResetView(), SetZoom()
  - Owns pan gesture handling internally
- Refactor ImagePyramidPage.xaml to use <demos:ImagePyramidView>
- Refactor ImagePyramidPage.xaml.cs: page manages provider lifecycle,
  passes Source/Provider to view, reads Zoom from view for slider sync

## Library change
- Add Canvas property to ISKImagePyramidRenderer interface
  Both SKImagePyramidRenderer and DebugBorderRenderer already had it;
  adding it to the interface allows view components to set the canvas
  on any renderer without casting to a concrete type.

All 828 tests pass (501 image pyramid + 188 core + 139 MAUI).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔴 HIGH: Blazor unmatched 'style' attribute runtime crash
- Add [Parameter(CaptureUnmatchedValues = true)] AdditionalAttributes
  to ImagePyramidView.razor.cs
- Splat @attributes on root <div> in ImagePyramidView.razor
- Removes the hardcoded 'ip-canvas-area' class so callers control layout

🟡 MEDIUM: MAUI event handler leak in ImagePyramidView.Cleanup()
- Store lambda in _invalidateHandler field instead of inline lambda
- -= with a new lambda is a no-op; the stored field reference correctly
  unsubscribes from InvalidateRequired on Cleanup()

🟡 MEDIUM: MAUI missing IIIF /info.json auto-append
- ImagePyramidPage.xaml.cs now appends /info.json to IIIF URLs before
  fetching, matching the Blazor sample's existing behaviour
- baseDir/stem now derived from fetchUrl (the resolved URL) not raw url

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant